Skip to content

feat(runway): git-backed merger with REBASE - #460

Open
behinddwalls wants to merge 1 commit into
preetam/runway-dlq-reconcilerfrom
preetam/runway-git-merger-rebase
Open

feat(runway): git-backed merger with REBASE#460
behinddwalls wants to merge 1 commit into
preetam/runway-dlq-reconcilerfrom
preetam/runway-git-merger-rebase

Conversation

@behinddwalls

@behinddwalls behinddwalls commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

Why?

Runway's merger extension has had exactly one implementation — noop, which always succeeds. Nothing actually merges anything. This lands the first real backend: a Merger driven by the git CLI against a local checkout.

It ships REBASE only. The strategy-specific apply paths are small and independent, but the machinery underneath them — the pinned git runtime, object resolution, the reset/apply/push cycle, contention retry, dry-run discard, conflict classification — is shared and is the bulk of what needs review. Landing it with one strategy keeps that review separable from the per-strategy mechanics that follow in this stack.

What?

Adds runway/extension/merger/git.

A change is a range of commits, not a commit. A change URI pins a pull request to a single head SHA, but a pull request is routinely several commits. Applying the head alone applies only that commit's diff against its own parent: it conflicts against context its predecessors would have established, or — when the commits touch different files — succeeds while silently dropping everything before the head. So the unit replayed is the range from the change's merge base with the target up to its head. A change already contained in the target has an empty range and is a no-op, which is what keeps redelivery idempotent.

A range runs through git's sequencer, which changes the control flow versus a single pick: the operation stops on a commit it declines and --skip advances to the next rather than ending. Two kinds of commit stop it harmlessly — one already present on the target, and one that was empty to begin with — and both are skipped. Anything else is a real conflict, and the in-progress pick is aborted so the checkout stays usable. The commits an apply produced are read back off the checkout rather than tracked per-invocation, which stays correct when the sequencer drops some.

Referenced commits are guaranteed present before anything is applied. The default fetch refspec is +refs/heads/*, which does not cover a provider's change refs: a pull request head never also pushed as a branch — the normal case for a fork — is simply absent, and the apply then fails with git's "bad object", indistinguishable from a conflict. Every commit the request names is now fetched and verified up front, for all steps, so an unusable request fails without having mutated the checkout. Commits are requested by SHA (relying on the server serving a want for a reachable-but-unadvertised object, which github.com allows), falling back to the provider's canonical ref, with deployment-supplied refspecs as a last resort. Neither fetch is shallow — the range needs ancestry.

A commit a reachable remote cannot supply is terminal; a remote that will not answer stays retryable. The first is a property of the request, the second a property of the moment.

One seam for change providers. Every URI is reduced to the only three things the merger needs — the commit to apply, the ref the provider publishes it under, and a label for synthesized messages. github:// and git:// are supported; an unrecognized scheme is terminal. Adding a provider is one case in that mapping rather than a change to any apply path.

Optional staleness check. Fetching by SHA guarantees the merger applies exactly the commit the URI names, not that it is still the change's head — a force-push leaves the superseded commit fetchable on most hosts. When enabled, each change's canonical ref is read (one ref advertisement, no object transfer) and a mismatch is terminal.

Atomicity, contention, dry run. Nothing reaches the remote until the final push. If the push is rejected because the remote tip moved, the whole reset/apply/push cycle retries up to a bounded number of attempts. CheckMergeability runs the identical apply path but never pushes, then resets and reports empty outputs, committing intermediate steps locally so a multi-step check sees the same conflict surface a real merge would.

Runtime hygiene. Every invocation uses an explicitly pinned git runtime and a scrubbed environment — no system or global config, no interactive prompts. That leaves no ambient identity, so the committer is injected per-invocation.

isConcreteStrategy currently admits only REBASE; SQUASH_REBASE, MERGE and PROMOTE are rejected as invalid requests until their apply paths land later in this stack. The merger is not wired into the server yet, so this adds no production behavior.

Test Plan

bazel test //runway/... — 6/6 targets pass, including the new //runway/extension/merger/git suite (40s)

The suite drives a real git binary against throwaway repositories. Beyond the single-commit cases (stacked URIs, already-landed changes, multi-step requests, conflicts, checkout recovery, contention retry, give-up after max attempts, DEFAULT resolution, dry runs), it covers what this change is actually about: a multi-commit change expressed as one URI for both disjoint and overlapping edits, a partially-landed change, an empty commit inside a range, a conflict inside a range leaving no sequencer state behind, stacked multi-commit changes not duplicating each other's commits, a head reachable only via its pull-request ref, an unavailable commit classified as invalid rather than conflicting, the staleness check on and off, and provider resolution across schemes.

The regression tests were verified to fail against head-only picking: reverting just that line fails 5 of them, and they pass with the range applied.

Stack

  1. feat(runway): dlq reconciler for merge topics #459
  2. @ feat(runway): git-backed merger with REBASE #460
  3. feat(runway): git merger SQUASH_REBASE and MERGE #461
  4. feat(runway): git merger PROMOTE #462
  5. feat(runway): wire the git merger into the server #463
  6. feat(runway): reject changes that disagree on provider #476

Comment thread runway/extension/merger/git/git_merger.go Outdated
Comment thread runway/extension/merger/git/git_merger.go
@behinddwalls
behinddwalls force-pushed the preetam/runway-dlq-reconciler branch from 6c0f8d9 to 42a0cd1 Compare July 30, 2026 16:12
@behinddwalls
behinddwalls force-pushed the preetam/runway-git-merger-rebase branch from ffa903d to bab2ff3 Compare July 30, 2026 16:12
@behinddwalls
behinddwalls marked this pull request as draft July 30, 2026 16:14
@behinddwalls
behinddwalls force-pushed the preetam/runway-dlq-reconciler branch from 42a0cd1 to d8ee8ae Compare July 30, 2026 16:16
@behinddwalls
behinddwalls force-pushed the preetam/runway-git-merger-rebase branch 2 times, most recently from b56440e to 4820747 Compare July 30, 2026 16:51
@behinddwalls
behinddwalls marked this pull request as ready for review July 30, 2026 17:06
@behinddwalls
behinddwalls force-pushed the preetam/runway-dlq-reconciler branch from d8ee8ae to b358333 Compare July 30, 2026 17:13
@behinddwalls
behinddwalls force-pushed the preetam/runway-git-merger-rebase branch from 4820747 to 8130e8f Compare July 30, 2026 17:13
@behinddwalls
behinddwalls force-pushed the preetam/runway-dlq-reconciler branch from b358333 to d976bb9 Compare July 30, 2026 17:55
@behinddwalls
behinddwalls force-pushed the preetam/runway-git-merger-rebase branch from 8130e8f to d2b5239 Compare July 30, 2026 17:55
## Summary

### Why?

Runway's `merger` extension has had exactly one implementation — `noop`, which always succeeds. Nothing actually merges anything. This lands the first real backend: a `Merger` driven by the `git` CLI against a local checkout.

It ships REBASE only. The strategy-specific apply paths are small and independent, but the machinery underneath them — the pinned git runtime, object resolution, the reset/apply/push cycle, contention retry, dry-run discard, conflict classification — is shared and is the bulk of what needs review. Landing it with one strategy keeps that review separable from the per-strategy mechanics that follow in this stack.

### What?

Adds `runway/extension/merger/git`.

**A change is a range of commits, not a commit.** A change URI pins a pull request to a single head SHA, but a pull request is routinely several commits. Applying the head alone applies only that commit's diff against its own parent: it conflicts against context its predecessors would have established, or — when the commits touch different files — succeeds while silently dropping everything before the head. So the unit replayed is the range from the change's merge base with the target up to its head. A change already contained in the target has an empty range and is a no-op, which is what keeps redelivery idempotent.

A range runs through git's sequencer, which changes the control flow versus a single pick: the operation stops on a commit it declines and `--skip` advances to the next rather than ending. Two kinds of commit stop it harmlessly — one already present on the target, and one that was empty to begin with — and both are skipped. Anything else is a real conflict, and the in-progress pick is aborted so the checkout stays usable. The commits an apply produced are read back off the checkout rather than tracked per-invocation, which stays correct when the sequencer drops some.

**Referenced commits are guaranteed present before anything is applied.** The default fetch refspec is `+refs/heads/*`, which does not cover a provider's change refs: a pull request head never also pushed as a branch — the normal case for a fork — is simply absent, and the apply then fails with git's "bad object", indistinguishable from a conflict. Every commit the request names is now fetched and verified up front, for all steps, so an unusable request fails without having mutated the checkout. Commits are requested by SHA (relying on the server serving a want for a reachable-but-unadvertised object, which github.com allows), falling back to the provider's canonical ref, with deployment-supplied refspecs as a last resort. Neither fetch is shallow — the range needs ancestry.

A commit a *reachable* remote cannot supply is terminal; a remote that will not answer stays retryable. The first is a property of the request, the second a property of the moment.

**One seam for change providers.** Every URI is reduced to the only three things the merger needs — the commit to apply, the ref the provider publishes it under, and a label for synthesized messages. `github://` and `git://` are supported; an unrecognized scheme is terminal. Adding a provider is one case in that mapping rather than a change to any apply path.

**Optional staleness check.** Fetching by SHA guarantees the merger applies exactly the commit the URI names, not that it is still the change's head — a force-push leaves the superseded commit fetchable on most hosts. When enabled, each change's canonical ref is read (one ref advertisement, no object transfer) and a mismatch is terminal.

**Atomicity, contention, dry run.** Nothing reaches the remote until the final push. If the push is rejected because the remote tip moved, the whole reset/apply/push cycle retries up to a bounded number of attempts. `CheckMergeability` runs the identical apply path but never pushes, then resets and reports empty outputs, committing intermediate steps locally so a multi-step check sees the same conflict surface a real merge would.

**Runtime hygiene.** Every invocation uses an explicitly pinned git runtime and a scrubbed environment — no system or global config, no interactive prompts. That leaves no ambient identity, so the committer is injected per-invocation.

`isConcreteStrategy` currently admits only REBASE; SQUASH_REBASE, MERGE and PROMOTE are rejected as invalid requests until their apply paths land later in this stack. The merger is not wired into the server yet, so this adds no production behavior.

## Test Plan

✅ `bazel test //runway/...` — 6/6 targets pass, including the new `//runway/extension/merger/git` suite (40s)

The suite drives a real git binary against throwaway repositories. Beyond the single-commit cases (stacked URIs, already-landed changes, multi-step requests, conflicts, checkout recovery, contention retry, give-up after max attempts, DEFAULT resolution, dry runs), it covers what this change is actually about: a multi-commit change expressed as **one** URI for both disjoint and overlapping edits, a partially-landed change, an empty commit inside a range, a conflict inside a range leaving no sequencer state behind, stacked multi-commit changes not duplicating each other's commits, a head reachable only via its pull-request ref, an unavailable commit classified as invalid rather than conflicting, the staleness check on and off, and provider resolution across schemes.

The regression tests were verified to fail against head-only picking: reverting just that line fails 5 of them, and they pass with the range applied.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant